Skip to content

refactor(desktop): make the session UI store the only pending authority - #4113

Draft
Astro-Han wants to merge 10 commits into
apache:mainfrom
Astro-Han:refactor/renderer-single-pending-authority
Draft

refactor(desktop): make the session UI store the only pending authority#4113
Astro-Han wants to merge 10 commits into
apache:mainfrom
Astro-Han:refactor/renderer-single-pending-authority

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Stacks on #4110. Its six commits are included here because its branch lives on a fork and cannot be a base. Review only the four commits from dc4087d91 onward; the diff against #4110 is 16 files, +360 / −440.

Summary

A simplification audit of the session-workspace slice, following the perf fix in #4110. Four concepts leave the renderer. Nothing is added that the code did not already imply.

One authority for "an action is in flight." Four facts each had two representations: a Set ref that the duplicate guard read, and a Record<string, boolean> in the session UI store that the disabled mask rendered — message retry, stop, permission mode, session model. Nothing read across the pair. They were kept aligned by hand at every claim, every finally, and two separate teardown paths that agreed only because one called the other in the right order.

The ref half is redundant: state replacement in the controller is synchronous, so a claim is visible to the next getState() in the same task, and the guard can read the map it already writes. createPendingClaim puts both halves behind one compare-and-set. Gone with it: addPendingSessionAction / clearPendingSessionAction and their optional-setter parameter that both call sites always passed, three private copies of omitSessionKey that existed only to maintain the map half, the four set*BySession setters those copies fed, and one of the two teardown paths.

The pending registry collapses onto the turn footer. useKeyedPendingRegistry was generic over trackState and autoClearMs because it served three instances. Two moved to the store above, and both options had only ever had one consumer — the turn footer needs the reactive snapshot for its disabled mask and the timers so a dropped sessions:changed cannot disable a button forever. useTurnActionRegistry says that directly. Separately, clearAll() had no caller: the unmount cleanup in app-shell-effects walked timersRef and keysRef itself, reaching around the method that exists to do it. It now calls clearAll(), which is why timersRef no longer needs to be public.

The store is reached through its controller. useAppShellSessionUiState returned the controller plus a member-by-member copy; useAppShellSessionWorkspace copied that copy; AppShell destructured the result. Adding a map meant editing three lists nothing keeps in agreement, and sessionUiController was already in the same destructure. The hook now returns the controller. 27 call sites name it — longer to read, shorter to keep true.

The delegating facade gets one home and a contract test. stable-actions.ts was split from its hook on the stated grounds that staying React-free made it testable from node:test. No such test existed and createDelegatingActions had one consumer. The claim is honoured the other way round: the facade moves in with useStableActions, becomes private, and the contract is asserted through the hook, where React's commit semantics are part of what is being promised.

Refs #4109

Verification

  • tsc for the renderer, main, and preload configs — clean
  • biome format across the repository, biome check on every touched file — clean
  • 59 affected unit tests across session settings, stop, chat send, session navigation, workspace action identity, and the new facade contract — pass
  • Playwright session-rail-render-contract — passes, which exercises this rewiring end to end in a real window

Both new assertions were checked for falsifiability. return actions in place of the facade fails with the facade itself is re-created; the pending-claim tests fail if the claim stops being compare-and-set.

Not run: the full repository suite.

Behaviour change

setPermissionMode now claims before its bypass confirmation rather than after, so a second click cannot open a second dialog. The cost is that the control reads as pending while the user decides, which is what is true. A cancelled confirmation releases the claim.

Review focus

The identity contract is the part worth arguing about. It constrains the mechanism — identities fixed across renders, calls delegating to the latest committed closures — rather than each of the nine useStableActions call sites, so it covers them by construction. That is deliberate: per-factory tests would need dependency graphs only AppShell can assemble, and they would assert the same thing nine times.

What no identity test can catch is a factory that goes through neither the facade nor a once-created object. A bare function declaration in a hook body — the actual #4109 defect — keeps its dependency arrays correct and has nothing for useExhaustiveDependencies to flag. That gap is why the Session rail also carries the outcome budget from #4110, and why the comment in session-workspace-actions.ts that used to argue one mechanism over the other now defers to the tests. Two mechanisms for stable identity remain, and that is intended: the facade exists for factories whose closures capture changing deps, and a factory whose deps are structurally constant should not pay for it.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code. It ran the audit that found these four, traced the demand chains behind each removal, wrote the change and the tests, and drafted this description. The human contributor reviewed the diff, the commit messages, and the behaviour change above. Generated-by trailers are on all four commits.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Behaviour change above
  • No

@github-actions github-actions Bot added the effort/XL Over 1000 readable lines label Aug 28, 2026
@Astro-Han
Astro-Han force-pushed the refactor/renderer-single-pending-authority branch from 9ccc637 to b292f0f Compare August 29, 2026 03:05
`setActiveId` and its siblings were function declarations in the
`useAppShellSessionWorkspace` body, so every AppShell render handed
consumers new identities. `activateSession` alone invalidated
`openSession`'s `useCallback`, then the Session navigation controller's
`commands`, then the host's `rowActions` and `onSelectSession`, then
`renderSessionRow` — which defeated `SessionNavRow`'s `memo` on every
commit. One session switch re-rendered all 32 sidebar rows about twenty
times, and each Astryx button rewrote its inline `anchor-name` per
render, so a switch also produced roughly 2,500 style writes.

Every dependency these actions close over is a ref box, a React state
setter, or a method of the once-created session-UI controller, so they
are constant by construction rather than by discipline.
`createSessionWorkspaceActions` moves them out of the render body and
the hook instantiates it once; `refreshSessions` and `seedSessions` get
the same treatment. This is why they are not routed through
`useStableActions`, whose facade exists for factories whose closures do
capture changing deps.

Measured by alternating the two identities inside one running instance,
six switches each: row renders 459 to 85, DOM mutations 3,438 to 1,992,
renderer JS 521 ms to 380 ms, and renderer CPU under a repeated-switch
loop 34% to 24% with peaks falling from 77% to 55%.

Two imports in the session-list hook gain their `.js` extension so the
workspace module tree loads under Node, which the new identity contract
test needs.

Generated-by: Claude Code
`@maka/ui`'s `formatAbsoluteTimestamp` was a second copy of the `Intl`
options `@maka/core/relative-time` already owned, and it built a
formatter on every call — the session sidebar reads one per row for the
tooltip and one for the row's accessible name, so a single session
switch constructed roughly 1,300 of them. Core's own cache could not
have absorbed that either: `getRelativeFormat` and `getAbsoluteFormat`
shared one `cachedLocale` and cleared each other on a miss, so
alternating readings of the same timestamp rebuilt a formatter every
call.

Core now caches each formatter with its own locale and exports
`formatAbsoluteTimestamp`; the UI copy is re-exported rather than
reimplemented, so the tooltip and the accessible name cannot drift.

Profiling attributed about 33 ms per session switch to the
constructions. An A/B inside one running instance, swapping a memoising
`Intl.DateTimeFormat` in and out six times each, moved renderer JS by
less than the run-to-run spread — this is a duplicate-authority removal,
not a measurable win.

Generated-by: Claude Code
The rail's cost has had several independent causes — `setActiveId`
changing identity on every AppShell render, `Intl` formatters rebuilt
per row, catalog refreshes replacing unchanged row objects — and each
was invisible to the others. Asserting identities pins one mechanism in
one hook; the next plain function declaration upstream passes every
existing check, because the dependency arrays stay correct.

So the assertion is on the outcome: switching a session may write at
most three inline styles per rail row. Inline `style` is the dominant
term, since every Astryx button removes and re-adds its `anchor-name`
per render, and it needs no React internals to observe — a
`MutationObserver` over the rail is the whole probe.

Measured on the new twelve-row fixture: 4 writes when the rail behaves,
the leaving and the arriving row at two each, stable across runs;
336 with `setActiveId` restored to a per-render identity. The budget of
36 sits an order of magnitude clear of both.

This also covers the unattributed commit cascade in apache#4109: whatever
raises the number of commits a switch produces shows up here.

Generated-by: Claude Code
The budget was a total, scaled by row count, and one-sided. Each of those
let a real regression through.

A total of `3 * rows` is 1.5 whole-rail renders, so a change that renders
the rail exactly once more than it should stayed under it — and that is
the likeliest regression, because `renderSessionRow` depends on
`rowActions`, `sessionMeta` and three Sets, any of which becoming a fresh
object per render defeats `SessionNavRow`'s memo for every row at once.
The identity test could not see it either: it reads the workspace hook's
return value, not what AppShell assembles from it. Attributing each write
to its row removes the hole and the row-count coupling together — a
switch touches the leaving row and the arriving row, whatever the rail's
length — and it asserts the fix's own missing middle, that memo holding
means untouched rows do no DOM work.

Counting remounts closes the other side: React sets attributes before
insertion, so an attribute-only observer reads a whole rail unmounting
and remounting as CHEAPER than a re-render.

`styleWrites > 0` is the counter's liveness check. Every write counted
comes from an Astryx ref callback with no `useCallback` around it; if
that is ever memoised upstream, healthy and regressed readings both
collapse to zero and a one-sided budget passes forever.

The two fixed `waitForTimeout` calls were the only thing keeping a slow
machine out of the measurement window, with `retries: 0` behind them.
Polling until the counter is quiet for ~300ms states the actual
precondition, and runs faster: 2.2s against 3.6s.

The identity test now derives its keys from the hook's return value.
The hand-kept list covered 11 of the 23 functions it returns and would
have kept covering 11 as more were added.

Verified by reverting the fix in the built renderer bundle: the run fails
on rows-touched, and passes three times in a row with the fix in place.

Generated-by: Claude Code
Extracting the workspace actions gave this type an owner and an export.
Leaving the three local copies in place would have made the PR that
merged one duplicate authority create another.

Generated-by: Claude Code
Once the implementation moved to `@maka/core/relative-time`, the export
left behind in `chat-display-helpers` held nothing — it was a second name
for the same function, and `relative-time.tsx` reached the one module
through both names at once. Drift is prevented by there being a single
implementation, not by which file the callers name.

`formatAbsoluteTimestamp` is not in the package's public exports, so this
moves three imports and removes a concept without changing a contract.

Generated-by: Claude Code
Four in-flight facts each had two representations: a `Set` ref that the
duplicate guard read, and a `Record<string, boolean>` in the session UI store
that the disabled mask rendered. Nothing read across the pair, and the two were
kept aligned by hand at every claim, every `finally`, and two separate teardown
paths — `clearOwnedSessionState` deleting from the refs, `clearSessionUiState`
wiping the maps, correct only because one calls the other in that order.

The ref half is redundant. State replacement in the controller is synchronous,
so a claim is visible to the next `getState()` in the same task; the guard can
read the map it already writes. `createPendingClaim` puts both halves behind
one compare-and-set, and message retry, stop, permission mode, and session model
each get a claim in place of a ref plus a setter plus a pair of AppShell
helpers.

Removed along the way: `addPendingSessionAction` and `clearPendingSessionAction`
with their unused optional-setter parameter, three private copies of
`omitSessionKey` that existed only to maintain the map half, the four
`set*BySession` setters those copies fed, and the two teardown paths that had to
agree.

One behaviour change. `setPermissionMode` now claims before its bypass
confirmation rather than after, so a second click cannot open a second dialog;
the control reads as pending while the user decides, which is what is true. A
cancelled confirmation releases the claim.

Generated-by: Claude Code
`useKeyedPendingRegistry` was generic over `trackState` and `autoClearMs`
because it served three instances. Two of them moved to the session UI store,
and both options had only ever had one consumer: the turn footer needs the
reactive snapshot for its disabled mask and the timers so a dropped
`sessions:changed` cannot disable a button forever. With no variation left, the
options object, the `trackState` branch, and the seeded-but-unused `keys` on a
ref-only registry are generality nothing asks for.

`clearAll()` had no caller. The unmount cleanup in `app-shell-effects` walked
`timersRef` and `keysRef` itself — the same work, reaching around the method
that exists to do it. It now calls `clearAll()`, which is why `timersRef` no
longer needs to be public.

Generated-by: Claude Code
The controller's surface was restated twice on the way to its consumers:
`useAppShellSessionUiState` returned the controller plus a member-by-member
copy of it, `useAppShellSessionWorkspace` copied that copy, and AppShell
destructured the result. Adding a map to the store meant editing three lists
that nothing keeps in agreement, and the copies carried no semantics of their
own — `sessionUiController` was already in the same destructure.

The hook now returns the controller. Call sites name it, which is longer to
read and shorter to keep true.

Generated-by: Claude Code
`stable-actions.ts` was split from its hook on the stated grounds that keeping
it React-free made it testable from `node:test`. No such test was ever written,
and `createDelegatingActions` had exactly one consumer. The claim is now
honoured the other way round: the facade moves in with `useStableActions` and
the contract is asserted through the hook, where React's commit semantics are
part of what is being promised.

The test covers all nine call sites by construction, because it constrains the
mechanism rather than each factory: identities fixed across renders, and calls
delegating to the latest committed closures. It fails on `return actions`.

What it cannot catch is a factory that goes through neither this hook nor a
once-created object — a bare function declaration in a hook body passes types
and `useExhaustiveDependencies` alike. That gap is why the Session rail carries
an outcome budget as well, and the comment in `session-workspace-actions.ts`
that argued for one mechanism over the other now defers to the tests instead.

Generated-by: Claude Code
@Astro-Han
Astro-Han force-pushed the refactor/renderer-single-pending-authority branch from b292f0f to 9df3019 Compare August 29, 2026 03:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Over 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant